昨天我們將要實作的流程與事前須申請的憑證都完成了,今天開始動手實作前端的部分~
在開賽的時候有提到,前端會使用到Angular框架,跳過環境建置的部分,我們直接從建立專案開始說明。
透過以下指令建置一個新專案:
ng new <your-project-name> --routing=true
routing flag 為 true 時專案中會自動包含 routes 的檔案,並註冊好路由。
第三方登入的功能我希望有三個頁面進行跳轉以清楚的展示登入前與後的狀況。
因此今天會建立的前端架構大致如下:
// 僅列出今天會用到的檔案,其他預設產生的檔案先跳過
src/app/
├── auth/
│   ├── login/login.component.ts 
│   ├── callback/callback.component.ts  
│   └── auth.service.ts               
│   └── auth.routes.ts               
├── home/
│   └── ...
├── environments/
│   ├── environment.prod.ts
│   └── environment.ts
事實上 如果只是要測試登入功能,僅有登入頁就足夠。 但考量到未來也會持續開發前端畫面,就在今天大概建立起簡單的主頁、跳轉過渡頁與登入頁,並配置簡單的模組路由。以下簡單說明各元件用途:
因為 login 與 callback 同屬 auth 相關元件,這邊還新增了 auth.routes.ts 用來定義 auth 相關路由。auth.service 則負責處理所有與身份驗證相關的狀態管理和 API 互動。
透過以下的指令快速建立 Compoennt :
ng g c <component-name>
會產生 .ts, .html, .css 和 .spec.ts 四個檔案,並自動配置必要的設定。
透過以下的指令建立 Service :
ng g s <service-name>
新增 enviroment.ts,配置以下內容,將我們昨天申請到的 Google OAuth Client ID 設定為環境變數:
export const environment = {
  production: false,
  googleClientId: 'Google OAuth2.0 Client ID'
};
auth.routes.ts
將 login 與 callback 加入 auth 底下作為子路由並 export 以便主路由設定。
export const AUTH_ROUTES: Routes = [
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: 'callback',
    component: CallbackComponent
  },
  {
    // 如果使用者只訪問 /auth,預設導向到登入頁
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  }
];
app.routes.ts
接著設定整個應用程式的路由,這個總路由在建立專案時就已被自動註冊到 config。
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/component/home/home.component';
export const routes: Routes = [
  {
    path: 'auth',
    loadChildren: () => import('./auth/auth.routes').then(m => m.AUTH_ROUTES)
  },
  {
    path: 'home',
    component: HomeComponent,
  },
  {
    path: '',
    redirectTo: '/home',
    pathMatch: 'full'
  },
];
Lazy Loading 意思是只有當使用者訪問 /auth 路徑時,才會去加載相關的認證模組,有助於提升初始載入效能。
用戶在登入頁面點擊「Sign in with Google」後,呼叫 loginWithGoogle ,導向驗證畫面。
login.component.html
<div class="login-container">
  <h1>登入頁面</h1>
  <button (click)="signIn()">
    Sign in with Google
  </button>
</div>
login.component.ts
...
export class LoginComponent {
  constructor(private authService: AuthService) { }
  signIn(): void {
    this.authService.loginWithGoogle();
  }
}
auth.service.ts
此時要請求的端點、要攜帶的參數,可參考這份 Google 文件 說明。
	loginWithGoogle(): void {
    const params = {
      client_id: environment.googleClientId,
      redirect_uri: 'http://localhost:4200/auth/callback',
      response_type: 'code',  // 指定為授權碼流程
      scope: 'openid profile email', // 有 openid 才會拿到 id_token
      access_type: 'offline', //加上這個參數才能取得 refresh token
      //測試時使用,加上這個參數每次登入即便 access token 未過期也會強制跳出授權頁面
      prompt: 'consent'
    };
    //帶著 Query String 導向驗證頁面,google才知道是哪個應用程式發出的請求
    const queryString = new URLSearchParams(params).toString();
    const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?${queryString}`;
    window.location.href = googleAuthUrl; 
  }
主頁簡單留有登入的前往登入頁的按鈕,屆時如果登入成功也會導回首頁
home.component.html
<h1> Home Page </h1>
<div class="home-container">
  <div class="profile-card not-logged-in">
    <button (click)="goToLogin()">前往登入頁面</button>
  </div>
</div>
home.component.ts
...
export class HomeComponent {
  constructor(
    private router: Router
  ) { }
  goToLogin(): void {
    this.router.navigate(['/auth/login']); // 執行導航到登入頁
  }
}
重新導向頁面僅為過渡頁使用,當 Google 將瀏覽器重新導向這個頁面時,會附上授權碼在後面,因此這個頁面初始化的第一件事情就是取得驗證碼並呼叫 handleAuthCallback 將授權碼傳至後端。
callback.component.html
<div class="callback-container">
  <p>正在進行身分驗證,請稍候...</p>
</div>
callback.component.ts
...
export class CallbackComponent implements OnInit {
  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private authService: AuthService
  ) { }
  ngOnInit(): void {
    this.handleAuthentication();
  }
  private handleAuthentication(): void {
	  // 從 URL 取得授權碼,並交給 AuthService 處理
    const code = this.route.snapshot.queryParamMap.get('code');
    const error = this.route.snapshot.queryParamMap.get('error');
    if (error) {
      console.error('Google 授權錯誤:', error);
      this.router.navigate(['/auth/login']); // 導回登入頁
      return;
    }
    if (code) {
      // 將 code 交給 AuthService 送到後端
      this.authService.handleAuthCallback(code);
    } else {
      // 如果沒有 code 也沒有 error
      console.error('在 Callback URL 中找不到授權碼');
      this.router.navigate(['/auth/login']);
    }
  }
}
auth.service.ts
將授權碼送到後端,屆時後端預計會回傳使用者資訊與我們自行簽發的 Token。
    /**
   * @description 處理從 Google 導回的授權碼,將其發送到後端。
   * @param code - 從 Google 取得的授權碼
   */
  handleAuthCallback(code: string): void {
    const backendApi = `${environment.apiBaseUrl}/users/auth/google`;
    this.http.post<UserProfile>(backendApi, { code }).subscribe({
      next: (user) => {
        console.log(user);
        this.router.navigate(['/home']);
      },
      error: (err) => {
        console.error('驗證碼取得成功,但後端 Token 交換失敗:', err);
        this.router.navigate(['/auth/login']);
      }
    });
  }
前端暫時以輸出回傳資訊並導回主頁告一段落,待明天我們處理好後端,確認回傳資料格式後,再接續前端接收使用者資訊進行後續處理的部分。
app.config.ts
為了能夠發送 API 請求,記得在 app.config.ts 中引入 provideHttpClient。
export const appConfig: ApplicationConfig = {
  // 加入 provideHttpClient()
  providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
};
上述功能完成後,我們可以啟動服務試試:
ng serve
此時,應該能夠完成以下流程:
http://localhost:4200,看到主頁,點擊按鈕後跳轉到登入頁。callback 頁面,然後迅速跳轉回主頁。看到這個錯誤是正常的,這代表我們的前端已經成功地從 Google 取得了授權碼,並嘗試將它發送到後端,只是後端 API 還沒準備好接收它。
今天準備好前端頁面,也確認取得授權碼的過程沒有問題,明天開始我們要實作後端端點,逐步完成解析 google id_token、資料建檔、token 簽發等身分驗證功能。